feat: custom telemetry configuration

This commit is contained in:
Abhishek Kumar 2026-03-23 11:36:39 +05:30
parent 1967a71935
commit affb39e57f
23 changed files with 927 additions and 139 deletions

View file

@ -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

View file

@ -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,

View file

@ -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]]:

View file

@ -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}"
)

View file

@ -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):

View file

@ -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",

View file

@ -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

View file

@ -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)

View 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

View file

@ -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()

View file

@ -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:

View file

@ -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}"

View file

@ -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}"
)

View file

@ -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

View file

@ -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}")

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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?: {

View 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>
);
}

View file

@ -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)
);