mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: custom telemetry configuration
This commit is contained in:
parent
1967a71935
commit
affb39e57f
23 changed files with 927 additions and 139 deletions
|
|
@ -27,6 +27,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from loguru import logger
|
||||
|
||||
from api.routes.main import router as main_router
|
||||
from api.services.pipecat.tracing_config import load_all_org_langfuse_credentials
|
||||
from api.tasks.arq import get_arq_redis
|
||||
|
||||
API_PREFIX = "/api/v1"
|
||||
|
|
@ -37,6 +38,10 @@ async def lifespan(app: FastAPI):
|
|||
# warmup arq pool
|
||||
await get_arq_redis()
|
||||
|
||||
# Pre-register all org-specific Langfuse exporters so they're ready
|
||||
# before any pipeline runs, without per-call DB lookups.
|
||||
await load_all_org_langfuse_credentials()
|
||||
|
||||
yield # Run app
|
||||
|
||||
# Shutdown sequence - this runs when FastAPI is shutting down
|
||||
|
|
|
|||
|
|
@ -365,13 +365,22 @@ class CampaignClient(BaseDBClient):
|
|||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_completed_runs_for_report(
|
||||
self, campaign_id: int
|
||||
) -> list[WorkflowRunModel]:
|
||||
"""Get completed workflow runs with call duration for campaign report CSV."""
|
||||
async def get_completed_runs_for_report(self, campaign_id: int) -> list:
|
||||
"""Get completed workflow runs for campaign report CSV.
|
||||
|
||||
Returns rows with only the columns needed for report generation.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowRunModel)
|
||||
select(
|
||||
WorkflowRunModel.id,
|
||||
WorkflowRunModel.created_at,
|
||||
WorkflowRunModel.initial_context,
|
||||
WorkflowRunModel.gathered_context,
|
||||
WorkflowRunModel.cost_info,
|
||||
WorkflowRunModel.logs,
|
||||
WorkflowRunModel.public_access_token,
|
||||
)
|
||||
.where(
|
||||
WorkflowRunModel.campaign_id == campaign_id,
|
||||
WorkflowRunModel.is_completed.is_(True),
|
||||
|
|
@ -379,14 +388,10 @@ class CampaignClient(BaseDBClient):
|
|||
.as_string()
|
||||
.isnot(None),
|
||||
)
|
||||
.order_by(
|
||||
WorkflowRunModel.cost_info["call_duration_seconds"]
|
||||
.as_float()
|
||||
.desc()
|
||||
)
|
||||
.order_by(WorkflowRunModel.created_at.desc())
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
return list(result.all())
|
||||
|
||||
async def create_queued_run(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,26 @@ class OrganizationConfigurationClient(BaseDBClient):
|
|||
config = await self.get_configuration(organization_id, key)
|
||||
return config.value if config else default
|
||||
|
||||
async def get_all_configurations_by_key(self, key: str) -> list[dict[str, Any]]:
|
||||
"""Get all organization configurations for a given key.
|
||||
|
||||
Returns a list of dicts with organization_id and the config value.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(OrganizationConfigurationModel).where(
|
||||
OrganizationConfigurationModel.key == key,
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"organization_id": config.organization_id,
|
||||
"value": config.value,
|
||||
}
|
||||
for config in result.scalars().all()
|
||||
if config.value
|
||||
]
|
||||
|
||||
async def get_configurations_by_provider(
|
||||
self, key: str, provider: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import hashlib
|
|||
import json
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import func, update
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import load_only, selectinload
|
||||
|
|
@ -174,6 +175,16 @@ class WorkflowClient(BaseDBClient):
|
|||
|
||||
return counts
|
||||
|
||||
async def get_workflow_organization_id(self, workflow_id: int) -> int | None:
|
||||
"""Fetch only the organization_id for a workflow. Lightweight query."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowModel.organization_id).where(
|
||||
WorkflowModel.id == workflow_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_workflow(
|
||||
self, workflow_id: int, user_id: int = None, organization_id: int = None
|
||||
) -> WorkflowModel | None:
|
||||
|
|
@ -434,3 +445,38 @@ class WorkflowClient(BaseDBClient):
|
|||
counts[workflow_id] = run_count
|
||||
|
||||
return counts
|
||||
|
||||
async def add_call_disposition_code(
|
||||
self, workflow_id: int, disposition_code: str
|
||||
) -> None:
|
||||
"""Add a disposition code to the workflow's call_disposition_codes if not already present.
|
||||
|
||||
The codes are stored as {"disposition_codes": ["code1", "code2", ...]}.
|
||||
"""
|
||||
if not disposition_code:
|
||||
return
|
||||
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowModel).where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
workflow = result.scalars().first()
|
||||
if not workflow:
|
||||
return
|
||||
|
||||
existing = workflow.call_disposition_codes or {}
|
||||
codes = existing.get("disposition_codes", [])
|
||||
if disposition_code in codes:
|
||||
return
|
||||
|
||||
codes.append(disposition_code)
|
||||
workflow.call_disposition_codes = {"disposition_codes": codes}
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
f"Failed to add disposition code '{disposition_code}' "
|
||||
f"to workflow {workflow_id}: {e}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ class OrganizationConfigurationKey(Enum):
|
|||
TWILIO_CONFIGURATION = (
|
||||
"TWILIO_CONFIGURATION" # Deprecated - for backward compatibility
|
||||
)
|
||||
LANGFUSE_CREDENTIALS = (
|
||||
"LANGFUSE_CREDENTIALS" # Org-level Langfuse tracing credentials
|
||||
)
|
||||
|
||||
|
||||
class WorkflowStatus(Enum):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
|
@ -10,7 +8,6 @@ from fastapi.responses import StreamingResponse
|
|||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from api.constants import (
|
||||
BACKEND_API_ENDPOINT,
|
||||
DEFAULT_CAMPAIGN_RETRY_CONFIG,
|
||||
DEFAULT_ORG_CONCURRENCY_LIMIT,
|
||||
)
|
||||
|
|
@ -18,12 +15,12 @@ from api.db import db_client
|
|||
from api.db.models import UserModel
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.campaign.report import generate_campaign_report_csv
|
||||
from api.services.campaign.runner import campaign_runner_service
|
||||
from api.services.campaign.source_sync import CampaignSourceSyncService
|
||||
from api.services.campaign.source_sync_factory import get_sync_service
|
||||
from api.services.quota_service import check_dograh_quota
|
||||
from api.services.storage import storage_fs
|
||||
from api.utils.transcript import generate_transcript_text
|
||||
|
||||
router = APIRouter(prefix="/campaign")
|
||||
|
||||
|
|
@ -705,14 +702,6 @@ async def get_campaign_source_download_url(
|
|||
)
|
||||
|
||||
|
||||
def _transcript_from_logs(logs: dict | None) -> str:
|
||||
"""Extract transcript text from workflow run logs JSON."""
|
||||
if not logs:
|
||||
return ""
|
||||
events = logs.get("realtime_feedback_events", [])
|
||||
return generate_transcript_text(events).strip()
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/report")
|
||||
async def download_campaign_report(
|
||||
campaign_id: int,
|
||||
|
|
@ -723,56 +712,8 @@ async def download_campaign_report(
|
|||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
runs = await db_client.get_completed_runs_for_report(campaign_id)
|
||||
output, filename = await generate_campaign_report_csv(campaign_id)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
[
|
||||
"Run ID",
|
||||
"Created At",
|
||||
"Customer Name",
|
||||
"Phone Number",
|
||||
"Call Disposition",
|
||||
"Call Tags",
|
||||
"Call Duration (s)",
|
||||
"Transcript",
|
||||
"Recording URL",
|
||||
]
|
||||
)
|
||||
|
||||
for run in runs:
|
||||
initial = run.initial_context or {}
|
||||
gathered = run.gathered_context or {}
|
||||
cost = run.cost_info or {}
|
||||
|
||||
recording_url = ""
|
||||
if run.public_access_token:
|
||||
recording_url = (
|
||||
f"{BACKEND_API_ENDPOINT}/api/v1/public/download/workflow"
|
||||
f"/{run.public_access_token}/recording"
|
||||
)
|
||||
|
||||
call_tags = gathered.get("call_tags", [])
|
||||
if isinstance(call_tags, list):
|
||||
call_tags = ", ".join(str(t) for t in call_tags)
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
run.id,
|
||||
run.created_at.isoformat() if run.created_at else "",
|
||||
initial.get("first_name", ""),
|
||||
initial.get("phone_number", ""),
|
||||
gathered.get("mapped_call_disposition", ""),
|
||||
call_tags,
|
||||
cost.get("call_duration_seconds", ""),
|
||||
_transcript_from_logs(run.logs),
|
||||
recording_url,
|
||||
]
|
||||
)
|
||||
|
||||
output.seek(0)
|
||||
filename = f"campaign_{campaign_id}_report.csv"
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="text/csv",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from api.schemas.telephony_config import (
|
|||
)
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.masking import is_mask_of, mask_key
|
||||
from api.services.pipecat.tracing_config import unregister_org_langfuse_credentials
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
|
||||
|
|
@ -248,6 +249,107 @@ def preserve_masked_fields(request, existing_config, config_value):
|
|||
config_value[field_name] = existing_config.value[field_name]
|
||||
|
||||
|
||||
class LangfuseCredentialsRequest(BaseModel):
|
||||
host: str
|
||||
public_key: str
|
||||
secret_key: str
|
||||
|
||||
|
||||
class LangfuseCredentialsResponse(BaseModel):
|
||||
host: str = ""
|
||||
public_key: str = ""
|
||||
secret_key: str = ""
|
||||
configured: bool = False
|
||||
|
||||
|
||||
@router.get("/langfuse-credentials", response_model=LangfuseCredentialsResponse)
|
||||
async def get_langfuse_credentials(user: UserModel = Depends(get_user)):
|
||||
"""Get Langfuse credentials for the user's organization with masked sensitive fields."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.LANGFUSE_CREDENTIALS.value,
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
return LangfuseCredentialsResponse()
|
||||
|
||||
return LangfuseCredentialsResponse(
|
||||
host=config.value.get("host", ""),
|
||||
public_key=mask_key(config.value.get("public_key", "")),
|
||||
secret_key=mask_key(config.value.get("secret_key", "")),
|
||||
configured=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/langfuse-credentials")
|
||||
async def save_langfuse_credentials(
|
||||
request: LangfuseCredentialsRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Save Langfuse credentials for the user's organization."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
existing_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.LANGFUSE_CREDENTIALS.value,
|
||||
)
|
||||
|
||||
config_value = {
|
||||
"host": request.host,
|
||||
"public_key": request.public_key,
|
||||
"secret_key": request.secret_key,
|
||||
}
|
||||
|
||||
# Preserve masked fields
|
||||
if existing_config and existing_config.value:
|
||||
if is_mask_of(request.public_key, existing_config.value.get("public_key", "")):
|
||||
config_value["public_key"] = existing_config.value["public_key"]
|
||||
if is_mask_of(request.secret_key, existing_config.value.get("secret_key", "")):
|
||||
config_value["secret_key"] = existing_config.value["secret_key"]
|
||||
|
||||
await db_client.upsert_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.LANGFUSE_CREDENTIALS.value,
|
||||
config_value,
|
||||
)
|
||||
|
||||
# Update the in-memory OTEL exporter so new traces route immediately
|
||||
from api.services.pipecat.tracing_config import register_org_langfuse_credentials
|
||||
|
||||
register_org_langfuse_credentials(
|
||||
org_id=user.selected_organization_id,
|
||||
host=config_value["host"],
|
||||
public_key=config_value["public_key"],
|
||||
secret_key=config_value["secret_key"],
|
||||
)
|
||||
|
||||
return {"message": "Langfuse credentials saved successfully"}
|
||||
|
||||
|
||||
@router.delete("/langfuse-credentials")
|
||||
async def delete_langfuse_credentials(user: UserModel = Depends(get_user)):
|
||||
"""Delete Langfuse credentials for the user's organization."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
deleted = await db_client.delete_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.LANGFUSE_CREDENTIALS.value,
|
||||
)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="No Langfuse credentials found")
|
||||
|
||||
# Remove the in-memory OTEL exporter so traces fall back to default
|
||||
unregister_org_langfuse_credentials(user.selected_organization_id)
|
||||
|
||||
return {"message": "Langfuse credentials deleted successfully"}
|
||||
|
||||
|
||||
class RetryConfigResponse(BaseModel):
|
||||
enabled: bool
|
||||
max_retries: int
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ from api.services.pipecat.ws_sender_registry import (
|
|||
)
|
||||
from api.services.quota_service import check_dograh_quota
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/ws")
|
||||
|
||||
|
|
@ -213,8 +213,12 @@ class SignalingManager:
|
|||
type_ = payload.get("type")
|
||||
call_context_vars = payload.get("call_context_vars", {})
|
||||
|
||||
# Set run context for logging
|
||||
# Set run context for logging and tracing. org_id must be set before
|
||||
# pc.initialize() so that aiortc's internal tasks inherit it.
|
||||
set_current_run_id(workflow_run_id)
|
||||
org_id = await db_client.get_workflow_organization_id(workflow_id)
|
||||
if org_id:
|
||||
set_current_org_id(org_id)
|
||||
|
||||
# Check Dograh quota before initiating the call
|
||||
quota_result = await check_dograh_quota(user)
|
||||
|
|
|
|||
96
api/services/campaign/report.py
Normal file
96
api/services/campaign/report.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import csv
|
||||
import io
|
||||
from typing import Any, List
|
||||
|
||||
from api.constants import BACKEND_API_ENDPOINT
|
||||
from api.db import db_client
|
||||
from api.utils.transcript import generate_transcript_text
|
||||
|
||||
|
||||
def _transcript_from_logs(logs: dict | None) -> str:
|
||||
"""Extract transcript text from workflow run logs JSON."""
|
||||
if not logs:
|
||||
return ""
|
||||
events = logs.get("realtime_feedback_events", [])
|
||||
return generate_transcript_text(events).strip()
|
||||
|
||||
|
||||
def _collect_extracted_variable_keys(runs: List[Any]) -> list[str]:
|
||||
"""Collect all unique extracted variable keys across runs, preserving insertion order."""
|
||||
keys: dict[str, None] = {}
|
||||
for run in runs:
|
||||
gathered = run.gathered_context or {}
|
||||
extracted = gathered.get("extracted_variables", {})
|
||||
if isinstance(extracted, dict):
|
||||
for key in extracted:
|
||||
keys.setdefault(key, None)
|
||||
return list(keys)
|
||||
|
||||
|
||||
async def generate_campaign_report_csv(campaign_id: int) -> tuple[io.StringIO, str]:
|
||||
"""Generate a CSV report for a campaign.
|
||||
|
||||
Returns a tuple of (csv_output, filename).
|
||||
"""
|
||||
runs = await db_client.get_completed_runs_for_report(campaign_id)
|
||||
|
||||
# Collect dynamic extracted variable columns
|
||||
extracted_var_keys = _collect_extracted_variable_keys(runs)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
pre_headers = [
|
||||
"Run ID",
|
||||
"Created At",
|
||||
"Phone Number",
|
||||
"Call Disposition",
|
||||
"Call Duration (s)",
|
||||
]
|
||||
post_headers = [
|
||||
"Call Tags",
|
||||
"Transcript",
|
||||
"Recording URL",
|
||||
]
|
||||
writer.writerow(pre_headers + extracted_var_keys + post_headers)
|
||||
|
||||
for run in runs:
|
||||
initial = run.initial_context or {}
|
||||
gathered = run.gathered_context or {}
|
||||
cost = run.cost_info or {}
|
||||
|
||||
recording_url = ""
|
||||
if run.public_access_token:
|
||||
recording_url = (
|
||||
f"{BACKEND_API_ENDPOINT}/api/v1/public/download/workflow"
|
||||
f"/{run.public_access_token}/recording"
|
||||
)
|
||||
|
||||
call_tags = gathered.get("call_tags", [])
|
||||
if isinstance(call_tags, list):
|
||||
call_tags = ", ".join(str(t) for t in call_tags)
|
||||
|
||||
pre_values = [
|
||||
run.id,
|
||||
run.created_at.isoformat() if run.created_at else "",
|
||||
initial.get("phone_number", ""),
|
||||
gathered.get("mapped_call_disposition", ""),
|
||||
cost.get("call_duration_seconds", ""),
|
||||
]
|
||||
|
||||
extracted = gathered.get("extracted_variables", {})
|
||||
if not isinstance(extracted, dict):
|
||||
extracted = {}
|
||||
extracted_values = [extracted.get(key, "") for key in extracted_var_keys]
|
||||
|
||||
post_values = [
|
||||
call_tags,
|
||||
_transcript_from_logs(run.logs),
|
||||
recording_url,
|
||||
]
|
||||
|
||||
writer.writerow(pre_values + extracted_values + post_values)
|
||||
|
||||
output.seek(0)
|
||||
filename = f"campaign_{campaign_id}_report.csv"
|
||||
return output, filename
|
||||
|
|
@ -9,6 +9,7 @@ from api.services.pipecat.in_memory_buffers import (
|
|||
InMemoryLogsBuffer,
|
||||
)
|
||||
from api.services.pipecat.pipeline_metrics_aggregator import PipelineMetricsAggregator
|
||||
from api.services.pipecat.tracing_config import get_trace_url
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.tasks.arq import enqueue_job
|
||||
from api.tasks.function_names import FunctionNames
|
||||
|
|
@ -139,10 +140,12 @@ def register_event_handlers(
|
|||
|
||||
# Add trace URL if available (must be done before conversation tracing ends)
|
||||
if task.turn_trace_observer:
|
||||
trace_url = task.turn_trace_observer.get_trace_url()
|
||||
if trace_url:
|
||||
gathered_context["trace_url"] = trace_url
|
||||
logger.debug(f"Added trace URL to gathered_context: {trace_url}")
|
||||
trace_id = task.turn_trace_observer.get_trace_id()
|
||||
if trace_id:
|
||||
trace_url = get_trace_url(trace_id)
|
||||
if trace_url:
|
||||
gathered_context["trace_url"] = trace_url
|
||||
logger.debug(f"Added trace URL to gathered_context: {trace_url}")
|
||||
|
||||
# also consider existing gathered context in workflow_run
|
||||
gathered_context = {**gathered_context, **workflow_run.gathered_context}
|
||||
|
|
@ -165,6 +168,19 @@ def register_event_handlers(
|
|||
|
||||
gathered_context["call_tags"] = call_tags
|
||||
|
||||
# Store disposition code in workflow for dynamic filtering
|
||||
disposition_code = gathered_context.get("mapped_call_disposition")
|
||||
if disposition_code and workflow_run:
|
||||
try:
|
||||
await db_client.add_call_disposition_code(
|
||||
workflow_run.workflow_id, disposition_code
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error storing disposition code in workflow: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Clean up engine resources (including voicemail detector)
|
||||
await engine.cleanup()
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ from api.services.pipecat.service_factory import (
|
|||
create_stt_service,
|
||||
create_tts_service,
|
||||
)
|
||||
from api.services.pipecat.tracing_config import setup_tracing_exporter
|
||||
from api.services.pipecat.tracing_config import (
|
||||
ensure_tracing,
|
||||
)
|
||||
from api.services.pipecat.transport_setup import (
|
||||
create_ari_transport,
|
||||
create_cloudonix_transport,
|
||||
|
|
@ -82,10 +84,10 @@ from pipecat.turns.user_stop import (
|
|||
)
|
||||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||
from pipecat.utils.enums import EndTaskReason, RealtimeFeedbackType
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
|
||||
# Setup tracing if enabled
|
||||
setup_tracing_exporter()
|
||||
ensure_tracing()
|
||||
|
||||
|
||||
async def run_pipeline_twilio(
|
||||
|
|
@ -108,6 +110,11 @@ async def run_pipeline_twilio(
|
|||
|
||||
# Get workflow to extract all pipeline configurations
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
|
|
@ -156,6 +163,7 @@ async def run_pipeline_vonage(
|
|||
"""
|
||||
logger.info(f"Starting Vonage pipeline for workflow run {workflow_run_id}")
|
||||
set_current_run_id(workflow_run_id)
|
||||
set_current_org_id(organization_id)
|
||||
|
||||
# Store call ID in cost_info for later cost calculation (provider-agnostic)
|
||||
cost_info = {"call_id": call_uuid}
|
||||
|
|
@ -226,6 +234,11 @@ async def run_pipeline_ari(
|
|||
|
||||
# Get workflow to extract configurations
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
|
|
@ -281,6 +294,11 @@ async def run_pipeline_vobiz(
|
|||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
|
|
@ -350,6 +368,11 @@ async def run_pipeline_cloudonix(
|
|||
|
||||
# Get workflow to extract all pipeline configurations
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
|
|
@ -397,6 +420,11 @@ async def run_pipeline_smallwebrtc(
|
|||
|
||||
# Get workflow to extract all pipeline configurations
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import base64
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.trace import SpanProcessor
|
||||
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
||||
|
||||
from api.constants import (
|
||||
ENABLE_TRACING,
|
||||
|
|
@ -10,43 +11,229 @@ from api.constants import (
|
|||
LANGFUSE_PUBLIC_KEY,
|
||||
LANGFUSE_SECRET_KEY,
|
||||
)
|
||||
from pipecat.utils.run_context import get_current_org_id
|
||||
from pipecat.utils.tracing.setup import setup_tracing
|
||||
|
||||
_tracing_initialized = False
|
||||
_org_routing_exporter = None
|
||||
|
||||
|
||||
def is_tracing_enabled():
|
||||
"""Check if tracing should be enabled based on ENABLE_TRACING flag."""
|
||||
# Tracing is only enabled when ENABLE_TRACING is explicitly set to true
|
||||
# This makes the system OSS-friendly by default (no external dependencies required)
|
||||
return ENABLE_TRACING
|
||||
class _OrgAttributeSpanProcessor(SpanProcessor):
|
||||
"""Stamps each span with the current org_id from the async context var."""
|
||||
|
||||
def on_start(self, span, parent_context=None):
|
||||
from pipecat.utils.run_context import get_current_org_id
|
||||
|
||||
org_id = get_current_org_id()
|
||||
if org_id:
|
||||
span.set_attribute("dograh.org_id", str(org_id))
|
||||
|
||||
def on_end(self, span):
|
||||
pass
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def force_flush(self, timeout_millis=30000):
|
||||
return True
|
||||
|
||||
|
||||
def setup_tracing_exporter():
|
||||
"""Setup the OTEL tracing exporter for Langfuse if enabled.
|
||||
class _OrgRoutingExporter(SpanExporter):
|
||||
"""Routes spans to org-specific or default Langfuse exporter.
|
||||
|
||||
Spans with a ``dograh.org_id`` attribute whose org has registered
|
||||
credentials are forwarded to that org's exporter. All other spans
|
||||
go to the default exporter (env-var credentials).
|
||||
"""
|
||||
|
||||
def __init__(self, default_exporter):
|
||||
self._default_exporter = default_exporter
|
||||
self._org_exporters = {}
|
||||
self._org_hosts = {}
|
||||
|
||||
def get_org_host(self, org_id):
|
||||
return self._org_hosts.get(str(org_id))
|
||||
|
||||
def register_org(self, org_id, host, public_key, secret_key):
|
||||
key = str(org_id)
|
||||
normalized_host = host.rstrip("/")
|
||||
auth = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
|
||||
endpoint = f"{normalized_host}/api/public/otel/v1/traces"
|
||||
|
||||
# Skip if already registered with identical settings
|
||||
if key in self._org_exporters:
|
||||
existing = self._org_exporters[key]
|
||||
if (
|
||||
self._org_hosts.get(key) == normalized_host
|
||||
and getattr(existing, "_endpoint", None) == endpoint
|
||||
and existing._headers.get("Authorization") == f"Basic {auth}"
|
||||
):
|
||||
return
|
||||
# Credentials changed — shut down the old exporter
|
||||
logger.info(f"Updating OTEL exporter for org {org_id}")
|
||||
existing.shutdown()
|
||||
|
||||
self._org_hosts[key] = normalized_host
|
||||
exporter = OTLPSpanExporter(
|
||||
endpoint=endpoint,
|
||||
headers={"Authorization": f"Basic {auth}"},
|
||||
)
|
||||
self._org_exporters[key] = exporter
|
||||
logger.info(f"Registered OTEL exporter for org {org_id}")
|
||||
|
||||
def unregister_org(self, org_id):
|
||||
key = str(org_id)
|
||||
exporter = self._org_exporters.pop(key, None)
|
||||
self._org_hosts.pop(key, None)
|
||||
if exporter:
|
||||
exporter.shutdown()
|
||||
logger.info(f"Unregistered OTEL exporter for org {org_id}")
|
||||
|
||||
def export(self, spans):
|
||||
default_spans = []
|
||||
org_buckets = {}
|
||||
|
||||
for span in spans:
|
||||
org_id = span.attributes.get("dograh.org_id") if span.attributes else None
|
||||
if org_id and str(org_id) in self._org_exporters:
|
||||
org_buckets.setdefault(str(org_id), []).append(span)
|
||||
else:
|
||||
default_spans.append(span)
|
||||
|
||||
result = SpanExportResult.SUCCESS
|
||||
|
||||
if default_spans and self._default_exporter:
|
||||
r = self._default_exporter.export(default_spans)
|
||||
if r != SpanExportResult.SUCCESS:
|
||||
result = r
|
||||
|
||||
for oid, batch in org_buckets.items():
|
||||
r = self._org_exporters[oid].export(batch)
|
||||
if r != SpanExportResult.SUCCESS:
|
||||
result = r
|
||||
|
||||
return result
|
||||
|
||||
def shutdown(self):
|
||||
if self._default_exporter:
|
||||
self._default_exporter.shutdown()
|
||||
for exp in self._org_exporters.values():
|
||||
exp.shutdown()
|
||||
|
||||
def force_flush(self, timeout_millis=30000):
|
||||
ok = True
|
||||
if self._default_exporter:
|
||||
ok = self._default_exporter.force_flush(timeout_millis) and ok
|
||||
for exp in self._org_exporters.values():
|
||||
ok = exp.force_flush(timeout_millis) and ok
|
||||
return ok
|
||||
|
||||
|
||||
def ensure_tracing() -> bool:
|
||||
"""Initialize OTEL tracing if enabled. Returns True if tracing is available.
|
||||
|
||||
Installs an ``_OrgRoutingExporter`` so that spans can be routed to
|
||||
org-specific Langfuse projects at export time.
|
||||
|
||||
Idempotent — safe to call from both the pipeline process and the ARQ worker.
|
||||
"""
|
||||
global _tracing_initialized
|
||||
global _tracing_initialized, _org_routing_exporter
|
||||
if _tracing_initialized:
|
||||
return
|
||||
return True
|
||||
|
||||
if is_tracing_enabled():
|
||||
if not all([LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY]):
|
||||
logger.warning(
|
||||
"Warning: ENABLE_TRACING is true but Langfuse credentials are not configured. Tracing disabled."
|
||||
)
|
||||
return
|
||||
if not ENABLE_TRACING:
|
||||
return False
|
||||
|
||||
# Build the default exporter from env-var credentials (may be None)
|
||||
default_exporter = None
|
||||
if all([LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY]):
|
||||
langfuse_auth = base64.b64encode(
|
||||
f"{LANGFUSE_PUBLIC_KEY}:{LANGFUSE_SECRET_KEY}".encode()
|
||||
).decode()
|
||||
|
||||
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = f"{LANGFUSE_HOST}/api/public/otel"
|
||||
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = (
|
||||
f"Authorization=Basic {langfuse_auth}"
|
||||
default_exporter = OTLPSpanExporter(
|
||||
endpoint=f"{LANGFUSE_HOST}/api/public/otel/v1/traces",
|
||||
headers={"Authorization": f"Basic {langfuse_auth}"},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"ENABLE_TRACING is true but default Langfuse credentials are not configured. "
|
||||
"Only org-level credentials will be used."
|
||||
)
|
||||
|
||||
otlp_exporter = OTLPSpanExporter()
|
||||
setup_tracing(service_name="dograh-pipeline", exporter=otlp_exporter)
|
||||
_tracing_initialized = True
|
||||
_org_routing_exporter = _OrgRoutingExporter(default_exporter)
|
||||
setup_tracing(service_name="dograh-pipeline", exporter=_org_routing_exporter)
|
||||
|
||||
# Add processor that stamps every span with the current org_id context var
|
||||
from opentelemetry import trace as otel_trace
|
||||
|
||||
provider = otel_trace.get_tracer_provider()
|
||||
if hasattr(provider, "add_span_processor"):
|
||||
provider.add_span_processor(_OrgAttributeSpanProcessor())
|
||||
|
||||
_tracing_initialized = True
|
||||
return True
|
||||
|
||||
|
||||
def register_org_langfuse_credentials(org_id, host, public_key, secret_key):
|
||||
"""Register or update org-specific Langfuse credentials for span routing.
|
||||
|
||||
Safe to call multiple times — updates credentials if they changed.
|
||||
"""
|
||||
if not ensure_tracing():
|
||||
return
|
||||
if not all([host, public_key, secret_key]):
|
||||
logger.warning(
|
||||
f"Incomplete Langfuse credentials for org {org_id}, skipping registration"
|
||||
)
|
||||
return
|
||||
_org_routing_exporter.register_org(org_id, host, public_key, secret_key)
|
||||
|
||||
|
||||
def unregister_org_langfuse_credentials(org_id):
|
||||
"""Remove org-specific Langfuse credentials. Spans will fall back to the default exporter."""
|
||||
if not ensure_tracing():
|
||||
return
|
||||
_org_routing_exporter.unregister_org(org_id)
|
||||
|
||||
|
||||
async def load_all_org_langfuse_credentials():
|
||||
"""Load Langfuse credentials for all orgs at startup.
|
||||
|
||||
Called once during app lifespan so that org-specific exporters are ready
|
||||
before any pipeline runs, without per-call DB lookups.
|
||||
"""
|
||||
if not ensure_tracing():
|
||||
return
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
|
||||
configs = await db_client.get_all_configurations_by_key(
|
||||
OrganizationConfigurationKey.LANGFUSE_CREDENTIALS.value,
|
||||
)
|
||||
for config in configs:
|
||||
org_id = config["organization_id"]
|
||||
value = config["value"]
|
||||
register_org_langfuse_credentials(
|
||||
org_id=org_id,
|
||||
host=value.get("host"),
|
||||
public_key=value.get("public_key"),
|
||||
secret_key=value.get("secret_key"),
|
||||
)
|
||||
logger.info(f"Loaded Langfuse credentials for {len(configs)} org(s)")
|
||||
|
||||
|
||||
def get_trace_url(trace_id: str, org_id=None) -> str | None:
|
||||
"""Build a Langfuse trace URL, using org-specific host when available."""
|
||||
if org_id is None:
|
||||
org_id = get_current_org_id()
|
||||
|
||||
host = None
|
||||
if org_id and _org_routing_exporter:
|
||||
host = _org_routing_exporter.get_org_host(str(org_id))
|
||||
if not host:
|
||||
host = LANGFUSE_HOST
|
||||
if not host:
|
||||
return None
|
||||
|
||||
return f"{host.rstrip('/')}/trace/{trace_id}"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ if TYPE_CHECKING:
|
|||
from pipecat.services.anthropic.llm import AnthropicLLMService
|
||||
from pipecat.services.google.llm import GoogleLLMService
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.utils.tracing.tracing_context import TracingContext
|
||||
|
||||
LLMService = Union[OpenAILLMService, AnthropicLLMService, GoogleLLMService]
|
||||
|
||||
|
|
@ -135,7 +136,9 @@ class PipecatEngine:
|
|||
Returns the turn-level context if available, otherwise the
|
||||
conversation-level context, or None.
|
||||
"""
|
||||
tracing_ctx = getattr(self.task, "_tracing_context", None)
|
||||
tracing_ctx: TracingContext | None = getattr(
|
||||
self.task, "_tracing_context", None
|
||||
)
|
||||
if not tracing_ctx:
|
||||
return None
|
||||
return tracing_ctx.get_turn_context() or tracing_ctx.get_conversation_context()
|
||||
|
|
@ -439,6 +442,10 @@ class PipecatEngine:
|
|||
)
|
||||
)
|
||||
self._gathered_context.update(extracted_data)
|
||||
extracted_variables = self._gathered_context.setdefault(
|
||||
"extracted_variables", {}
|
||||
)
|
||||
extracted_variables.update(extracted_data)
|
||||
logger.debug(
|
||||
f"Variable extraction completed. Extracted: {extracted_data}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from loguru import logger
|
|||
from opentelemetry import trace
|
||||
|
||||
from api.services.gen_ai.json_parser import parse_llm_json
|
||||
from api.services.pipecat.tracing_config import is_tracing_enabled
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
from api.services.workflow.dto import ExtractionVariableDTO
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
|
||||
|
|
@ -206,7 +206,7 @@ class VariableExtractionManager:
|
|||
# Get model name for tracing
|
||||
model_name = getattr(self._engine.llm, "model_name", "unknown")
|
||||
|
||||
if is_tracing_enabled():
|
||||
if ensure_tracing():
|
||||
tracer = trace.get_tracer("pipecat")
|
||||
with tracer.start_as_current_span(
|
||||
"llm-variable-extraction", context=parent_ctx
|
||||
|
|
|
|||
|
|
@ -5,18 +5,21 @@ import re
|
|||
from loguru import logger
|
||||
|
||||
from api.db.models import WorkflowRunModel
|
||||
from api.services.pipecat.tracing_config import get_trace_url
|
||||
|
||||
|
||||
def extract_trace_id(gathered_context: dict) -> str | None:
|
||||
"""Extract Langfuse trace_id from gathered_context trace_url.
|
||||
|
||||
URL format: https://langfuse.dograh.com/project/<project_id>/traces/<trace_id>
|
||||
Supports both URL formats:
|
||||
- New: https://langfuse.dograh.com/trace/<trace_id>
|
||||
- Legacy: https://langfuse.dograh.com/project/<project_id>/traces/<trace_id>
|
||||
"""
|
||||
trace_url = gathered_context.get("trace_url")
|
||||
if not trace_url:
|
||||
return None
|
||||
try:
|
||||
match = re.search(r"/traces/([a-fA-F0-9]+)$", trace_url)
|
||||
match = re.search(r"/traces?/([a-fA-F0-9]+)$", trace_url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
|
|
@ -37,16 +40,11 @@ def setup_langfuse_parent_context(workflow_run: WorkflowRunModel):
|
|||
set_span_in_context,
|
||||
)
|
||||
|
||||
from api.services.pipecat.tracing_config import (
|
||||
is_tracing_enabled,
|
||||
setup_tracing_exporter,
|
||||
)
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
|
||||
if not is_tracing_enabled():
|
||||
if not ensure_tracing():
|
||||
return None
|
||||
|
||||
setup_tracing_exporter()
|
||||
|
||||
gathered_context = workflow_run.gathered_context or {}
|
||||
trace_id = extract_trace_id(gathered_context)
|
||||
if not trace_id:
|
||||
|
|
@ -114,17 +112,12 @@ def create_node_summary_trace(
|
|||
from opentelemetry import trace as otel_trace
|
||||
from opentelemetry.context import Context
|
||||
|
||||
from api.services.pipecat.tracing_config import (
|
||||
is_tracing_enabled,
|
||||
setup_tracing_exporter,
|
||||
)
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
|
||||
|
||||
if not is_tracing_enabled():
|
||||
if not ensure_tracing():
|
||||
return None
|
||||
|
||||
setup_tracing_exporter()
|
||||
|
||||
tracer = otel_trace.get_tracer("pipecat")
|
||||
|
||||
# Create a root span (new trace) for this node summary generation
|
||||
|
|
@ -144,10 +137,7 @@ def create_node_summary_trace(
|
|||
)
|
||||
trace_id = format(span.get_span_context().trace_id, "032x")
|
||||
|
||||
from langfuse import get_client
|
||||
|
||||
langfuse = get_client()
|
||||
return langfuse.get_trace_url(trace_id=trace_id)
|
||||
return get_trace_url(trace_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create node summary trace for '{node_name}': {e}")
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from opentelemetry import trace
|
|||
|
||||
from api.db import db_client
|
||||
from api.services.gen_ai import OpenAIEmbeddingService
|
||||
from api.services.pipecat.tracing_config import is_tracing_enabled
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
|
||||
|
||||
async def retrieve_from_knowledge_base(
|
||||
|
|
@ -51,7 +51,7 @@ async def retrieve_from_knowledge_base(
|
|||
- total_results: Number of results returned
|
||||
"""
|
||||
# Create span for retrieval operation if tracing is enabled
|
||||
if is_tracing_enabled():
|
||||
if ensure_tracing():
|
||||
try:
|
||||
parent_context = tracing_context
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ from loguru import logger
|
|||
from api.constants import BACKEND_API_ENDPOINT
|
||||
from api.db import db_client
|
||||
from api.db.models import WorkflowRunModel
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.services.pipecat.tracing_config import register_org_langfuse_credentials
|
||||
from api.services.workflow.qa import run_per_node_qa_analysis
|
||||
from api.utils.credential_auth import build_auth_header
|
||||
from api.utils.template_renderer import render_template
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
|
||||
|
||||
def _should_skip_qa(
|
||||
|
|
@ -169,6 +171,22 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int):
|
|||
logger.warning("No organization found, skipping integrations")
|
||||
return
|
||||
|
||||
# Set org context for tracing and register org-specific Langfuse credentials
|
||||
# FIXME: If an org removes langfuse credentials during an exisitng deployment
|
||||
# we should unregister an existing langfuse credentials for that org.
|
||||
set_current_org_id(organization_id)
|
||||
langfuse_config = await db_client.get_configuration_value(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.LANGFUSE_CREDENTIALS.value,
|
||||
)
|
||||
if langfuse_config:
|
||||
register_org_langfuse_credentials(
|
||||
org_id=organization_id,
|
||||
host=langfuse_config.get("host"),
|
||||
public_key=langfuse_config.get("public_key"),
|
||||
secret_key=langfuse_config.get("secret_key"),
|
||||
)
|
||||
|
||||
# Step 2: Get workflow definition (prefer the run-specific definition)
|
||||
if workflow_run.definition:
|
||||
workflow_definition = workflow_run.definition.workflow_json
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { StackHandler } from "@stackframe/stack";
|
||||
|
||||
import { TelemetrySection } from "@/components/TelemetrySection";
|
||||
import { getAuthProvider } from "@/lib/auth/config";
|
||||
|
||||
import { BackButton } from "./BackButton";
|
||||
|
|
@ -28,6 +29,18 @@ export default async function Handler(props: unknown) {
|
|||
fullPage
|
||||
app={app!}
|
||||
routeProps={props}
|
||||
componentProps={{
|
||||
AccountSettings: {
|
||||
extraItems: [
|
||||
{
|
||||
id: "telemetry",
|
||||
title: "Telemetry",
|
||||
iconName: "Key",
|
||||
content: <TelemetrySection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
|
||||
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
||||
import { WorkflowRunsTable } from "@/components/workflow-runs";
|
||||
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
|
||||
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
|
||||
|
|
@ -60,17 +59,15 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
});
|
||||
|
||||
const workflow = response.data;
|
||||
if (workflow?.call_disposition_codes) {
|
||||
// Update the disposition code attribute with actual options
|
||||
const codes = workflow?.call_disposition_codes?.disposition_codes;
|
||||
if (codes && codes.length > 0) {
|
||||
setConfiguredAttributes(prev => prev.map(attr => {
|
||||
if (attr.id === 'dispositionCode') {
|
||||
return {
|
||||
...attr,
|
||||
config: {
|
||||
...attr.config,
|
||||
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
|
||||
? Object.keys(workflow.call_disposition_codes || {})
|
||||
: [...DISPOSITION_CODES]
|
||||
options: codes,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -732,6 +732,19 @@ export type IntegrationResponse = {
|
|||
|
||||
export type ItemKind = 'node' | 'edge' | 'workflow';
|
||||
|
||||
export type LangfuseCredentialsRequest = {
|
||||
host: string;
|
||||
public_key: string;
|
||||
secret_key: string;
|
||||
};
|
||||
|
||||
export type LangfuseCredentialsResponse = {
|
||||
host?: string;
|
||||
public_key?: string;
|
||||
secret_key?: string;
|
||||
configured?: boolean;
|
||||
};
|
||||
|
||||
export type LastCampaignSettingsResponse = {
|
||||
retry_config?: RetryConfigResponse | null;
|
||||
max_concurrency?: number | null;
|
||||
|
|
@ -3802,6 +3815,101 @@ export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostRespo
|
|||
200: unknown;
|
||||
};
|
||||
|
||||
export type DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/langfuse-credentials';
|
||||
};
|
||||
|
||||
export type DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteError = DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteErrors[keyof DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteErrors];
|
||||
|
||||
export type DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/langfuse-credentials';
|
||||
};
|
||||
|
||||
export type GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetError = GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetErrors[keyof GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetErrors];
|
||||
|
||||
export type GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: LangfuseCredentialsResponse;
|
||||
};
|
||||
|
||||
export type GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponse = GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponses[keyof GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponses];
|
||||
|
||||
export type SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostData = {
|
||||
body: LangfuseCredentialsRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/langfuse-credentials';
|
||||
};
|
||||
|
||||
export type SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostError = SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostErrors[keyof SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostErrors];
|
||||
|
||||
export type SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
131
ui/src/components/TelemetrySection.tsx
Normal file
131
ui/src/components/TelemetrySection.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDelete,
|
||||
getLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGet,
|
||||
saveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPost,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { LangfuseCredentialsResponse } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function TelemetrySection() {
|
||||
const [credentials, setCredentials] = useState<LangfuseCredentialsResponse>({
|
||||
host: "",
|
||||
public_key: "",
|
||||
secret_key: "",
|
||||
configured: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, []);
|
||||
|
||||
async function fetchCredentials() {
|
||||
try {
|
||||
const { data } = await getLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGet();
|
||||
if (data) {
|
||||
setCredentials(data);
|
||||
}
|
||||
} catch {
|
||||
// No credentials configured yet — that's fine
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const { error } = await saveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPost({
|
||||
body: {
|
||||
host: credentials.host ?? "",
|
||||
public_key: credentials.public_key ?? "",
|
||||
secret_key: credentials.secret_key ?? "",
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
throw new Error("Failed to save");
|
||||
}
|
||||
toast.success("Telemetry credentials saved");
|
||||
await fetchCredentials();
|
||||
} catch {
|
||||
toast.error("Failed to save telemetry credentials");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDelete();
|
||||
setCredentials({ host: "", public_key: "", secret_key: "", configured: false });
|
||||
toast.success("Telemetry credentials removed");
|
||||
} catch {
|
||||
toast.error("Failed to remove telemetry credentials");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect your Langfuse project to receive call tracing data.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="langfuse-host">Host</Label>
|
||||
<Input
|
||||
id="langfuse-host"
|
||||
placeholder="https://cloud.langfuse.com"
|
||||
value={credentials.host}
|
||||
onChange={(e) => setCredentials({ ...credentials, host: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="langfuse-public-key">Public Key</Label>
|
||||
<Input
|
||||
id="langfuse-public-key"
|
||||
placeholder="pk-lf-..."
|
||||
value={credentials.public_key}
|
||||
onChange={(e) => setCredentials({ ...credentials, public_key: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="langfuse-secret-key">Secret Key</Label>
|
||||
<Input
|
||||
id="langfuse-secret-key"
|
||||
type="password"
|
||||
placeholder="sk-lf-..."
|
||||
value={credentials.secret_key}
|
||||
onChange={(e) => setCredentials({ ...credentials, secret_key: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
{credentials.configured && (
|
||||
<Button type="button" variant="destructive" disabled={saving} onClick={handleDelete}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getCampaignRunsApiV1CampaignCampaignIdRunsGet } from "@/client/sdk.gen";
|
||||
import { getCampaignRunsApiV1CampaignCampaignIdRunsGet, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
|
||||
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
||||
import { WorkflowRunsTable } from "@/components/workflow-runs";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
|
@ -49,6 +49,40 @@ export function CampaignRuns({ campaignId, workflowId, searchParams }: CampaignR
|
|||
return searchParams ? decodeFiltersFromURL(searchParams, availableAttributes) : [];
|
||||
});
|
||||
|
||||
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
|
||||
|
||||
// Load disposition codes from workflow configuration
|
||||
const loadDispositionCodes = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
try {
|
||||
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
|
||||
path: { workflow_id: workflowId },
|
||||
});
|
||||
|
||||
const workflow = response.data;
|
||||
const codes = workflow?.call_disposition_codes?.disposition_codes;
|
||||
setConfiguredAttributes(prev => prev.map(attr => {
|
||||
if (attr.id === 'dispositionCode') {
|
||||
return {
|
||||
...attr,
|
||||
config: {
|
||||
...attr.config,
|
||||
options: codes,
|
||||
}
|
||||
};
|
||||
}
|
||||
return attr;
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load disposition codes:", err);
|
||||
}
|
||||
}, [workflowId, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDispositionCodes();
|
||||
}, [loadDispositionCodes]);
|
||||
|
||||
const fetchCampaignRuns = useCallback(async (
|
||||
page: number,
|
||||
filters?: ActiveFilter[],
|
||||
|
|
@ -176,7 +210,7 @@ export function CampaignRuns({ campaignId, workflowId, searchParams }: CampaignR
|
|||
}, [fetchCampaignRuns, currentPage, appliedFilters, sortBy, sortOrder]);
|
||||
|
||||
// Use a subset of filter attributes relevant for campaigns
|
||||
const campaignFilterAttributes: FilterAttribute[] = availableAttributes.filter(
|
||||
const campaignFilterAttributes: FilterAttribute[] = configuredAttributes.filter(
|
||||
attr => ['dateRange', 'dispositionCode', 'duration', 'status', 'tokenUsage'].includes(attr.id)
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue