feat(analytics): integrate Langfuse with Keycloak SSO
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m49s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Security Audit (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Deploy (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled

Add certifai-langfuse OIDC client to Keycloak realm export and configure
the Langfuse Docker service with Keycloak SSO env vars (shared realm,
account linking, local auth disabled). Replace the iframe-based analytics
page with an informational landing since cross-origin SSO breaks in
iframes. Users open Langfuse in a new tab where the active Keycloak
session authenticates them transparently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-02-25 20:54:47 +01:00
parent c165841766
commit 70095734d0
10 changed files with 333 additions and 26 deletions

View File

@@ -118,7 +118,16 @@
"agents_col_name": "Name", "agents_col_name": "Name",
"agents_col_id": "ID", "agents_col_id": "ID",
"agents_col_description": "Beschreibung", "agents_col_description": "Beschreibung",
"agents_col_status": "Status" "agents_col_status": "Status",
"analytics_status_connected": "Verbunden",
"analytics_status_not_connected": "Nicht verbunden",
"analytics_config_hint": "Setzen Sie LANGFUSE_URL in .env, um eine Verbindung herzustellen",
"analytics_sso_hint": "Langfuse nutzt Keycloak-SSO. Sie werden automatisch mit Ihrem CERTifAI-Konto angemeldet.",
"analytics_quick_actions": "Schnellaktionen",
"analytics_traces": "Traces",
"analytics_traces_desc": "Alle LLM-Aufrufe, Latenzen und Token-Verbrauch anzeigen und filtern.",
"analytics_dashboard": "Dashboard",
"analytics_dashboard_desc": "Ueberblick ueber Kosten, Qualitaetsmetriken und Nutzungstrends."
}, },
"org": { "org": {
"title": "Organisation", "title": "Organisation",

View File

@@ -118,7 +118,16 @@
"agents_col_name": "Name", "agents_col_name": "Name",
"agents_col_id": "ID", "agents_col_id": "ID",
"agents_col_description": "Description", "agents_col_description": "Description",
"agents_col_status": "Status" "agents_col_status": "Status",
"analytics_status_connected": "Connected",
"analytics_status_not_connected": "Not Connected",
"analytics_config_hint": "Set LANGFUSE_URL in .env to connect",
"analytics_sso_hint": "Langfuse uses Keycloak SSO. You will be signed in automatically with your CERTifAI account.",
"analytics_quick_actions": "Quick Actions",
"analytics_traces": "Traces",
"analytics_traces_desc": "View and filter all LLM call traces, latencies, and token usage.",
"analytics_dashboard": "Dashboard",
"analytics_dashboard_desc": "Overview of costs, quality metrics, and usage trends."
}, },
"org": { "org": {
"title": "Organization", "title": "Organization",

View File

@@ -118,7 +118,16 @@
"agents_col_name": "Nombre", "agents_col_name": "Nombre",
"agents_col_id": "ID", "agents_col_id": "ID",
"agents_col_description": "Descripcion", "agents_col_description": "Descripcion",
"agents_col_status": "Estado" "agents_col_status": "Estado",
"analytics_status_connected": "Conectado",
"analytics_status_not_connected": "No conectado",
"analytics_config_hint": "Configure LANGFUSE_URL en .env para conectar",
"analytics_sso_hint": "Langfuse utiliza SSO de Keycloak. Iniciara sesion automaticamente con su cuenta CERTifAI.",
"analytics_quick_actions": "Acciones rapidas",
"analytics_traces": "Trazas",
"analytics_traces_desc": "Ver y filtrar todas las llamadas LLM, latencias y uso de tokens.",
"analytics_dashboard": "Panel de control",
"analytics_dashboard_desc": "Resumen de costos, metricas de calidad y tendencias de uso."
}, },
"org": { "org": {
"title": "Organizacion", "title": "Organizacion",

View File

@@ -118,7 +118,16 @@
"agents_col_name": "Nom", "agents_col_name": "Nom",
"agents_col_id": "ID", "agents_col_id": "ID",
"agents_col_description": "Description", "agents_col_description": "Description",
"agents_col_status": "Statut" "agents_col_status": "Statut",
"analytics_status_connected": "Connecte",
"analytics_status_not_connected": "Non connecte",
"analytics_config_hint": "Definissez LANGFUSE_URL dans .env pour vous connecter",
"analytics_sso_hint": "Langfuse utilise le SSO Keycloak. Vous serez connecte automatiquement avec votre compte CERTifAI.",
"analytics_quick_actions": "Actions rapides",
"analytics_traces": "Traces",
"analytics_traces_desc": "Afficher et filtrer tous les appels LLM, latences et consommation de tokens.",
"analytics_dashboard": "Tableau de bord",
"analytics_dashboard_desc": "Apercu des couts, metriques de qualite et tendances d'utilisation."
}, },
"org": { "org": {
"title": "Organisation", "title": "Organisation",

View File

@@ -118,7 +118,16 @@
"agents_col_name": "Nome", "agents_col_name": "Nome",
"agents_col_id": "ID", "agents_col_id": "ID",
"agents_col_description": "Descricao", "agents_col_description": "Descricao",
"agents_col_status": "Estado" "agents_col_status": "Estado",
"analytics_status_connected": "Conectado",
"analytics_status_not_connected": "Nao conectado",
"analytics_config_hint": "Defina LANGFUSE_URL no .env para conectar",
"analytics_sso_hint": "O Langfuse utiliza SSO do Keycloak. Sera autenticado automaticamente com a sua conta CERTifAI.",
"analytics_quick_actions": "Acoes rapidas",
"analytics_traces": "Traces",
"analytics_traces_desc": "Ver e filtrar todas as chamadas LLM, latencias e uso de tokens.",
"analytics_dashboard": "Painel",
"analytics_dashboard_desc": "Resumo de custos, metricas de qualidade e tendencias de uso."
}, },
"org": { "org": {
"title": "Organizacao", "title": "Organizacao",

View File

@@ -3614,11 +3614,102 @@ h6 {
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.agents-page { .agents-page,
.analytics-page {
padding: 20px 16px; padding: 20px 16px;
} }
.agents-grid { .agents-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
}
/* ===== Analytics Page ===== */
.analytics-page {
display: flex;
flex-direction: column;
padding: 32px;
gap: 32px;
}
.analytics-hero {
max-width: 720px;
display: flex;
flex-direction: column;
gap: 12px;
}
.analytics-hero-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
.analytics-hero-icon {
width: 48px;
height: 48px;
min-width: 48px;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: var(--avatar-text);
border-radius: 12px;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.analytics-hero-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 28px;
font-weight: 700;
color: var(--text-heading);
margin: 0;
}
.analytics-hero-desc {
font-size: 15px;
color: var(--text-muted);
line-height: 1.6;
max-width: 600px;
margin: 0;
}
.analytics-sso-hint {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
margin: 0;
}
.analytics-launch-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: var(--avatar-text);
font-size: 14px;
font-weight: 600;
border-radius: 8px;
text-decoration: none;
transition: opacity 0.2s, transform 0.2s;
width: fit-content;
}
.analytics-launch-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.analytics-stats-bar {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.analytics-stats-bar {
flex-direction: column;
}
} }

View File

@@ -139,6 +139,8 @@ services:
container_name: certifai-langfuse container_name: certifai-langfuse
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
keycloak:
condition: service_healthy
langfuse-db: langfuse-db:
condition: service_healthy condition: service_healthy
langfuse-clickhouse: langfuse-clickhouse:
@@ -155,6 +157,13 @@ services:
NEXTAUTH_SECRET: certifai-langfuse-dev-secret NEXTAUTH_SECRET: certifai-langfuse-dev-secret
SALT: certifai-langfuse-dev-salt SALT: certifai-langfuse-dev-salt
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
# Keycloak OIDC SSO - shared realm with CERTifAI dashboard
AUTH_KEYCLOAK_CLIENT_ID: certifai-langfuse
AUTH_KEYCLOAK_CLIENT_SECRET: certifai-langfuse-secret
AUTH_KEYCLOAK_ISSUER: http://keycloak:8080/realms/certifai
AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING: "true"
# Disable local email/password auth (SSO only)
AUTH_DISABLE_USERNAME_PASSWORD: "true"
CLICKHOUSE_URL: http://langfuse-clickhouse:8123 CLICKHOUSE_URL: http://langfuse-clickhouse:8123
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000 CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
CLICKHOUSE_USER: clickhouse CLICKHOUSE_USER: clickhouse

View File

@@ -123,17 +123,51 @@ test.describe("Developer section", () => {
} }
}); });
test("analytics page shows Not Configured when URL is empty", async ({ test("analytics page renders informational landing", async ({ page }) => {
await page.goto("/developer/analytics");
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
// Hero section
await expect(page.locator(".analytics-hero-title")).toBeVisible();
await expect(page.locator(".analytics-hero-desc")).toBeVisible();
// Connection status indicator
await expect(page.locator(".agents-status")).toBeVisible();
// Metrics bar
await expect(page.locator(".analytics-stats-bar")).toBeVisible();
});
test("analytics page shows Not Connected when URL is empty", async ({
page, page,
}) => { }) => {
await page.goto("/developer/analytics"); await page.goto("/developer/analytics");
await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); await page.waitForSelector(".analytics-page", { timeout: 15_000 });
await expect( await expect(page.locator(".agents-status")).toContainText(
page.locator("h2", { hasText: "Analytics" }) "Not Connected"
).toBeVisible();
await expect(page.locator(".placeholder-badge")).toContainText(
"Not Configured"
); );
await expect(page.locator(".agents-status-dot--off")).toBeVisible();
});
test("analytics page shows quick action cards", async ({ page }) => {
await page.goto("/developer/analytics");
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
const grid = page.locator(".agents-grid");
const cards = grid.locator(".agents-card, .agents-card--disabled");
await expect(cards).toHaveCount(2);
});
test("analytics page shows SSO hint when connected", async ({ page }) => {
// Only meaningful when LANGFUSE_URL is configured.
await page.goto("/developer/analytics");
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
const connectedDot = page.locator(".agents-status-dot--on");
if (await connectedDot.isVisible()) {
await expect(page.locator(".analytics-sso-hint")).toBeVisible();
await expect(page.locator(".analytics-launch-btn")).toBeVisible();
}
}); });
}); });

View File

@@ -79,6 +79,39 @@
"offline_access" "offline_access"
] ]
}, },
{
"clientId": "certifai-langfuse",
"name": "CERTifAI Langfuse",
"description": "Langfuse OIDC client for CERTifAI",
"enabled": true,
"publicClient": false,
"directAccessGrantsEnabled": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"secret": "certifai-langfuse-secret",
"rootUrl": "http://localhost:3000",
"baseUrl": "http://localhost:3000",
"redirectUris": [
"http://localhost:3000/*"
],
"webOrigins": [
"http://localhost:3000",
"http://localhost:8000"
],
"attributes": {
"post.logout.redirect.uris": "http://localhost:3000"
},
"defaultClientScopes": [
"openid",
"profile",
"email"
],
"optionalClientScopes": [
"offline_access"
]
},
{ {
"clientId": "certifai-librechat", "clientId": "certifai-librechat",
"name": "CERTifAI Chat", "name": "CERTifAI Chat",

View File

@@ -1,14 +1,18 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBarChartLine, BsBoxArrowUpRight, BsGraphUp, BsSpeedometer,
};
use dioxus_free_icons::Icon;
use crate::components::ToolEmbed;
use crate::i18n::{t, Locale}; use crate::i18n::{t, Locale};
use crate::models::{AnalyticsMetric, ServiceUrlsContext}; use crate::models::{AnalyticsMetric, ServiceUrlsContext};
/// Analytics page embedding Langfuse for observability. /// Analytics & Observability page for Langfuse.
/// ///
/// Always shows a stats bar with sample metrics. Below that, when /// Langfuse is configured with Keycloak SSO (shared realm with CERTifAI).
/// `langfuse_url` is configured, embeds the service in an iframe /// When users open Langfuse, the existing Keycloak session auto-authenticates
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder. /// them transparently. This page shows a metrics bar, connection status,
/// and a prominent button to open Langfuse in a new tab.
#[component] #[component]
pub fn AnalyticsPage() -> Element { pub fn AnalyticsPage() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
@@ -16,10 +20,55 @@ pub fn AnalyticsPage() -> Element {
let l = *locale.read(); let l = *locale.read();
let url = svc.read().langfuse_url.clone(); let url = svc.read().langfuse_url.clone();
let connected = !url.is_empty();
let metrics = mock_metrics(l); let metrics = mock_metrics(l);
rsx! { rsx! {
section { class: "placeholder-page", div { class: "analytics-page",
// -- Hero section --
div { class: "analytics-hero",
div { class: "analytics-hero-row",
div { class: "analytics-hero-icon",
Icon { icon: BsGraphUp, width: 24, height: 24 }
}
h2 { class: "analytics-hero-title",
{t(l, "developer.analytics_title")}
}
}
p { class: "analytics-hero-desc",
{t(l, "developer.analytics_desc")}
}
// -- Connection status --
if connected {
div { class: "agents-status",
span {
class: "agents-status-dot agents-status-dot--on",
}
span { {t(l, "developer.analytics_status_connected")} }
code { class: "agents-status-url", {url.clone()} }
}
} else {
div { class: "agents-status",
span {
class: "agents-status-dot agents-status-dot--off",
}
span { {t(l, "developer.analytics_status_not_connected")} }
span { class: "agents-status-hint",
{t(l, "developer.analytics_config_hint")}
}
}
}
// -- SSO info --
if connected {
p { class: "analytics-sso-hint",
{t(l, "developer.analytics_sso_hint")}
}
}
}
// -- Metrics bar --
div { class: "analytics-stats-bar", div { class: "analytics-stats-bar",
for metric in &metrics { for metric in &metrics {
div { class: "analytics-stat", div { class: "analytics-stat",
@@ -36,13 +85,59 @@ pub fn AnalyticsPage() -> Element {
} }
} }
} }
}
ToolEmbed { // -- Open Langfuse button --
url, if connected {
title: t(l, "developer.analytics_title"), a {
description: t(l, "developer.analytics_desc"), class: "analytics-launch-btn",
icon: "L", href: "{url}",
launch_label: t(l, "developer.launch_analytics"), target: "_blank",
rel: "noopener noreferrer",
Icon { icon: BsBoxArrowUpRight, width: 16, height: 16 }
span { {t(l, "developer.launch_analytics")} }
}
}
// -- Quick actions --
h3 { class: "agents-section-title",
{t(l, "developer.analytics_quick_actions")}
}
div { class: "agents-grid",
// Traces
a {
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
href: if connected { format!("{url}/project") } else { "#".to_string() },
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsBarChartLine, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.analytics_traces")}
}
div { class: "agents-card-desc",
{t(l, "developer.analytics_traces_desc")}
}
}
// Dashboard
a {
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
href: if connected { format!("{url}/project") } else { "#".to_string() },
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsSpeedometer, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.analytics_dashboard")}
}
div { class: "agents-card-desc",
{t(l, "developer.analytics_dashboard_desc")}
}
}
}
} }
} }
} }